常见锁策略 |
您所在的位置:网站首页 › 偏向锁 cas › 常见锁策略 |
目录 1.常见锁策略 1.1乐观锁vs悲观锁 1.2轻量级锁vs重量级锁 1.3自旋锁vs挂起等待锁 自旋锁 挂起等待锁 1.4互斥锁vs读写锁 1.5公平锁vs非公平锁 公平锁 非公平锁 1.6可重入锁vs不可重入锁 1.7使用锁策略描述synchronized 2.CAS(Compare And Swap) 2.1CAS应用场景 实现原子类 实现自旋锁 2.2CAS的ABA问题 3.synchronized原理 3.1锁升级/锁膨胀 无锁 偏向锁 轻量级锁 重量级锁 3.2锁消除 3.3锁粗化 1.常见锁策略锁策略不仅仅局限于java,任何与"锁"相关的话题(操作系统,数据库...),都会涉及到锁策略,这些策略是给锁的实现者用来参考的 1.1乐观锁vs悲观锁这个不是两把具体的锁.而是两类锁,是在锁冲突的概率上进行区分的 乐观锁指的是预测锁竞争不是很激烈(做的工作相对少一些),悲观锁预测锁竞争会很激烈(这里做的工作会多一些). 1.2轻量级锁vs重量级锁是从锁开销的角度区分的 轻量级锁加锁解锁开销比较小,效率更高.重量级锁加锁解锁开销比较大,效率更低. 多数情况下,乐观锁也是一个轻量级锁,悲观锁也是一个重量级锁 1.3自旋锁vs挂起等待锁自旋锁是典型的轻量级锁 挂起等待锁是典型的重量级锁] 自旋锁自旋锁伪代码: while (抢锁(lock) ==失败) {} 复制代码自旋锁如果获取锁失败,立即再尝试获取锁,无限循环..一旦锁被其他线程释放,就能第一时间获取到锁 自旋锁的优点: 没有放弃cpu,不涉及线程阻塞和调度,一旦锁被释放,就饿能第一时间获取到锁 缺点: 如果锁被其它线程持有的时间较长,那么就会持续的消耗cpu资源(挂起等待是不需要消耗资源的) 挂起等待锁挂起等待锁:如果一个锁被另外的线程持有,挂起等待锁会一直等待,不会主动去获取锁 这种做法不会消耗大量cpu资源,就可以做别的工作了. 1.4互斥锁vs读写锁互斥锁 提供加锁和解锁操作,就像我们使用过的synchronized这样的锁.如果一个线程加锁了,另一个线程也尝试获取锁,就会阻塞等待 读写锁 提供了三种操作 1.针对读加锁 2.针对写加锁 多线程针对同一个变量并发读是没有线程安全问题的.也不需要加锁. 读锁和读锁之间没有互斥 写锁和写锁之间是互斥的 写锁和读锁之间存在互斥 假设一组线程并发读同一个变量,这时线程之间是没有锁竞争的,也没有线程安全问题!假设一组线程有读又有写,才会产生锁竞争..实际开发中,读操作非常高频 3.解锁 1.5公平锁vs非公平锁 公平锁把公平锁定义为"先来后到" B比C先来获取锁然后阻塞等待的,当A释放锁之后,B就能先于C获取到锁 非公平锁不遵守"先来后到" 不管BC谁先来的,当A释放锁之后,BC都有可能获取到锁,synchronized就是非公平锁! 操作系统内部的线程调度就是随机的,如果不做额外的限制,锁就是非公平锁,如果要实现公平锁,就需要额外的数据结构来保存先后顺序 公平锁和非公平锁没有优劣,要看适用的场景 1.6可重入锁vs不可重入锁不可重入锁:一个线程针对同一把锁,连续加锁两次,出现死锁 可重入锁:一个线程针对同一把锁,连续加锁多次都不会出现死锁 1.7使用锁策略描述synchronized上述种锁策略,就像是锁的形容词.任何一个锁,都能用上述锁策略来描述,形容,我们看synchronized是怎样的 1.synchronized既是一个悲观锁,又是个乐观锁 synchronized默认是乐观锁,但是如果发现锁竞争比较激烈,就会变成悲观锁!! 2.synchronized既是轻量级锁,又是一个重量级锁 synchronized默认是轻量级锁,当锁冲突剧烈后,就变成重量级锁! 3.synchronized这里的轻量级锁是基于自旋锁的方式实现的 synchronized这里的重量级锁是基于挂起等待锁的方式实现的 4.synchronized不是读写锁 5.synchronized是非公平锁 6.synchronized是可重入锁 2.CAS(Compare And Swap)一个CAS涉及到以下操作: 我们假设内存中的原数据V,旧的预期值A,需要修改的新值B 1.比较A与V是否相等 2.如果相等,将B写入V 3.返回操作是否成功 编辑 上述交换过程中,大多数不关心B后续的情况了,更关心的是V这个变量的情况.近似可以理解成赋值了 如果AV不同,则没有其他操作 我们看一下CAS的伪代码: boolean CAS(V,A,B){ if(A == V){ V = B; return true; } return false; } 复制代码但是CAS的过程并非是通过代码实现的!!而是通过一条CPU指令完成的!CAS操作是原子的,因此它是线程安全的.那么解决线程安全问题除了加锁,就又有个新的思路了. CAS是CPU提供的一个特殊指令,通过这个指令,就可以一定程度的处理线程安全问题! 2.1CAS应用场景 实现原子类Java标准库中提供的有原子类,之前我们学习线程安全时,写过一个问题,两个线程对同一个变量进行自增操作后,这个变量没有达到预期的结果,我们是通过加锁解决线程安全问题的.这里我们直接使用原子类,就不会出现线程安全问题 AtomicInteger count = new AtomicInteger(); 复制代码AtomicInteger是原子类,基于CAS实现了自增,自减等操作,此时进行自增等操作不需要加锁,也线程安全的 public class Test { public static void main(String[] args) throws InterruptedException { //使用原子类解决线程安全问题 AtomicInteger count = new AtomicInteger(); Thread t1 = new Thread(()->{ for (int i = 0; i < 50000; i++) { count.getAndIncrement(); } }); Thread t2 = new Thread(()->{ for (int i = 0; i < 50000; i++) { count.getAndIncrement(); } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } } 复制代码结果: 编辑 我们看一下伪代码实现的原子类 class AtomicInteger { private int value; public int getAndIncrement() { int oldValue = value; while ( CAS(value, oldValue, oldValue+1) != true) { oldValue = value; } return oldValue; } } 复制代码这里的oldValue可以理解为是寄存器中的值,相当于先把内存中的值读到寄存器里 正常情况下,oldValue应该是和value的值是相同的,然后这里发生CAS,把old Value+1写到value中 但是也可能会有:执行完读取value到寄存器中后,线程切换了,另外一个线程也修改了内存中value的值,此时这个线程如果继续执行进行CAS判定,就会认为value和oldValue不相等了 value和oldValue不相等,然后重新读取oldValue 我们画图解释一下这个过程: 编辑 按照这个时间执行两个线程 t1,t2都进行加载 编辑 然后t2开始CAS 比较oldValue和value的值,发现相等,oldValue+1赋给value 编辑 t2线程执行完毕,切换回t1线程,t1线程开始CAS,发现oldValue和value的值不相等,返回false,不进行任何交换...然后进入循环,循环内部重新读取value的值到oldValue 中,此时再次比较,发现相等了,进行CAS操作,并返回true,循环结束 编辑 原子类这里的实现,每次修改之前都会再确认一下这个值是否符合要求 CAS是属于特殊方法,特定场景能使用,加锁操作是通用方式,各种场景都能使用,打击面很广! 实现自旋锁我们看一下自旋锁的伪代码 public class SpinLock { private Thread owner = null; public void lock(){ // 通过 CAS 看当前锁是否被某个线程持有. // 如果这个锁已经被别的线程持有, 那么就自旋等待. // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. while(!CAS(this.owner, null, Thread.currentThread())){ } } public void unlock (){ this.owner = null; } } 复制代码Thread owner是记录当前锁是谁加的 this.owner是检测当前的owner是否是null,如果是null的,就进行交换,也就是把当前的线程的引用赋值给owner.如果赋值成功,此时循环结束,加锁完成! 如果当前锁已经被别的线程占用了,那么owner就不是null的,那么CAS就不会产生赋值,同时返回false,循环继续执行,进行下次判断,这就完成了自旋过程!! 在Java中,并不是直接提供了一个方法CAS.此处伪代码是便于理解 2.2CAS的ABA问题CAS在运行中的核心是检查oldValue和value是否一致,如果一致,就认为value中途没有被修改过.所以进行下一步操作是没问题的 但是还有可能是中途被修改过,然后又还原回来了.把value值设为A,CAS判定value为A,此时value确实可能始终是A,也有可能本来是A,然后被修改为B,最后又还原成了A!这就是ABA问题 ABA情况大部分是不会对代码/逻辑产生太大影响的,当然也有极端情况,我们看下面这个情景: 如果ATM取钱使用的是CAS来扣款,假设A的账户余额1000,要取500.当按下取款按键时,机器卡顿了,A没忍住多按了几下,此时就会产生bug,可能出现重复扣款的现象 正常情况下,机器卡顿多按两次,t1线程的CAS发现余额是1000,然后就交换成500.扣款成功,然后t2线程加载时余额也是1000,CAS发现余额不是1000,就不扣款.正确的逻辑 编辑 下面这种情况,当t2执行CAS的时候,正好有人给A转入了500.那么余额就变成1000了, 执行CAS操作,又扣了500,出现了bug!! 编辑 当然这种情况出现的概率是很低的,但是还是可能出现,针对这种情况,采取的解决方案就是加入一个版本号,初始版本号是1,每次修改版本号都加1,然后进行CAS的时候,不是以金额多少为准了,是以版本号为准,此时如果版本号没变,就一定没有发生改变 3.synchronized原理两个线程针对同一个变量加锁,就会阻塞等待.除了上述基本原理,synchronized还有一些内部的优化机制,存在的目的就是为了让锁更高效,好用. 3.1锁升级/锁膨胀当执行到加锁的代码块儿时,加锁过程就可能经历下面几个升级阶段 无锁无锁状态,还没开始加锁 偏向锁进行加锁的时候,首先会进入偏向锁状态 偏向锁,并不是真正的加锁,而只是先占个位置,如果有需要就加锁,没需要就不加锁了 相当于"懒汉模式"提到的懒加载一样,非必要,不加锁 synchronized加锁的时候,并不是真正的加锁,而是先进入偏向锁状态,就相当于做一个标记,如果一直没有别的线程来获取这个锁,那么就不会升级,仅仅只做个标记,因为这个变量本来就只有这个线程要使用,过程也没有出现锁竞争,执行完synchronized{}代码块后,再取消掉标记(偏向锁)即可 但是如果出现了锁竞争,再另一个线程加锁之前,偏向锁会迅速升级为真正的加锁状态!!另一个线程阻塞等待... 轻量级锁当synchronized发生锁竞争的时候,就会从偏向锁升级为轻量级锁(自旋锁) 此时,synchronized是通过自旋的方式来进行加锁的(就和刚刚伪代码一样的逻辑) 但是,如果很快就释放锁了,自旋是值得的,可以立即获取被释放的锁,反之,迟迟不被释放,那么久迟迟拿不到锁,自旋就不划算了..这时候就需要再次升级了! 重量级锁一直自旋但是又拿不到锁,synchronized也不会无止境的自旋,此时升级为重量级锁(挂起等待锁) 重量级锁(挂起等待锁)则是基于操作系统原生的API来进行加锁了 linux原生提供了mutex一组API,操作系统北河提供的加锁功能,这个锁是会影响到线程的调度的 此时,如果线程进行了重量级锁的加锁,并且发生了锁竞争,此时线程就会被放入阻塞队列中,暂时不参加CPU的调度了,直到锁被释放了,这个线程才有机会被调度到并有机会获取到锁 锁升级了就不能降级了 3.2锁消除这是编译器的智能判定,看当前代码是否真的需要加锁,如果这个场景不用加锁,就会自动把加的锁销毁 就像StringBuffer中的关键的方法都是带有synchronized修饰的,就不需要程序员再加锁,加了编译器也会自动销毁! 3.3锁粗化锁的粒度:synchronized包含的代码越多,粒度就越粗.包含的代码越少,粒度就越细. 通常情况下,粒度细一点比较好,加锁的代码是不能并发执行的,锁的粒度越细,能并发的代码就越多,粒度越粗,能并发的越少. 有些情况,粒度粗反而更好 编辑 这种情况下,两次加锁解锁之间的间隙非常小,反反复复加锁解锁效率低开销大,可以直接加一个大锁,将间隙也包括,效率反而高些,毕竟间隙很小,这块儿代码能不能并发执行影响不大! |
CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3 |